feat: unified IdP group mapping (OIDC + SCIM)#61
Conversation
Greptile SummaryThis PR decouples SCIM group state from the
Key remaining behavioral limitation: SCIM member remove and full-replace PUT are both additive-only. The code is clear and well-commented about why (no provenance field on Confidence Score: 3/5
Sequence DiagramsequenceDiagram
participant IdP as Identity Provider
participant SCIM as SCIM Endpoint
participant GM as group-mappings.ts
participant DB as PostgreSQL
IdP->>SCIM: POST /Groups {displayName, externalId, members?}
SCIM->>DB: findUnique ScimGroup (by displayName)
alt Group exists
SCIM->>DB: update ScimGroup (externalId if changed)
SCIM-->>IdP: 200 OK (updated record)
else New group
SCIM->>DB: create ScimGroup
SCIM-->>IdP: 201 Created (no member provisioning)
end
IdP->>SCIM: PATCH /Groups/{id} ops:[add members]
SCIM->>GM: loadGroupMappings()
GM->>DB: findUnique SystemSettings.oidcTeamMappings
GM-->>SCIM: GroupMapping[]
SCIM->>GM: getMappingsForGroup(allMappings, displayName)
loop each member userId
SCIM->>GM: applyMappedMemberships(tx, userId, groupMappings)
loop each mapping
GM->>DB: findUnique TeamMember
alt No existing membership
GM->>DB: create TeamMember (mapped role)
else Existing role is lower
GM->>DB: update TeamMember (upgrade role only)
end
end
end
SCIM-->>IdP: 200 OK
IdP->>SCIM: PATCH /Groups/{id} ops:[remove members]
Note over SCIM: Remove is a no-op (no provenance tracking)<br/>Access revoked only on next OIDC login
SCIM-->>IdP: 200 OK (membership retained until next OIDC login)
IdP->>SCIM: PUT /Groups/{id} {displayName, members}
SCIM->>GM: loadGroupMappings() + getMappingsForGroup()
Note over SCIM: Additive-only sync — adds present members, never removes
SCIM-->>IdP: 200 OK
IdP->>SCIM: DELETE /Groups/{id}
SCIM->>DB: delete ScimGroup (TeamMembers NOT cascaded)
SCIM-->>IdP: 204 No Content
Last reviewed commit: 5f6d8ef |
- POST: return updated adopted record instead of stale pre-update object - PUT: change from destructive full-sync to additive-only member sync to prevent cross-group deprovisioning in multi-group scenarios - DELETE: remove team member cascade that would wipe all members from mapped teams regardless of membership provenance
|
@greptile review. ┌──────────────────────┬─────────────────────────────────┬────────────────────────────────┐ |
- PATCH member remove is now a no-op: without membership provenance tracking, removing would silently revoke access granted by other groups, OIDC, or manual assignment (same safeguard as DELETE/PUT) - Remove removeMappedMemberships helper (no longer used) - Process displayName rename before member ops and re-resolve mappings, preventing stale mapping context when rename + member ops are batched
|
@greptile fixed. PATCH remove cross-group data │ Made remove a no-op with comment; deleted │ |
…rships - PUT handler now re-resolves groupMappings after displayName rename, matching the fix already applied to PATCH - applyMappedMemberships only upgrades roles, never downgrades — prevents a lower-role group sync from overwriting a higher role granted by another group, OIDC, or manual assignment
|
@greptile fixed Finding 1 (PUT stale mappings): Same pattern we fixed in PATCH — re-resolve after rename. Finding 2 (role downgrade): Real issue. If "admins" maps to Engineering/ADMIN and "developers" |
| // Member remove is intentionally a no-op. Without tracking which | ||
| // group granted each TeamMember, removing here would silently | ||
| // revoke access still legitimately granted by other groups, OIDC, | ||
| // or manual assignment. Memberships reconcile on next OIDC login. | ||
| } |
There was a problem hiding this comment.
SCIM deprovisioning is silently non-functional — contradicts test plan
The code comment documents the no-op as intentional, which is a valid safety tradeoff. However the PR description's test plan states:
SCIM removes member — verify TeamMember removed
This test will always pass the protocol layer (the IdP gets a 200 OK) but will fail the intent — the TeamMember record is never removed by SCIM. Access is only revoked when the user next signs in via OIDC and their groups claim no longer includes this group.
This behaviour gap has real operator impact:
- An admin removes a user from a sensitive IdP group expecting immediate access revocation
- VectorFlow acknowledges the remove with
200 OK - The user retains team access until their next OIDC login (which could be days or never, if they have an active session)
The test plan item should either be updated to document this limitation, or a comment should be added to the docs describing that SCIM-sourced deprovisioning is eventual (OIDC-login-gated), not immediate. Operators need to know to communicate this to their security teams when evaluating the integration.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/api/scim/v2/Groups/[id]/route.ts
Line: 96-100
Comment:
SCIM deprovisioning is silently non-functional — contradicts test plan
The code comment documents the no-op as intentional, which is a valid safety tradeoff. However the PR description's test plan states:
> SCIM removes member — verify TeamMember removed
This test will always pass the protocol layer (the IdP gets a `200 OK`) but will **fail the intent** — the `TeamMember` record is never removed by SCIM. Access is only revoked when the user next signs in via OIDC and their groups claim no longer includes this group.
This behaviour gap has real operator impact:
- An admin removes a user from a sensitive IdP group expecting immediate access revocation
- VectorFlow acknowledges the remove with `200 OK`
- The user retains team access until their next OIDC login (which could be days or never, if they have an active session)
The test plan item should either be updated to document this limitation, or a comment should be added to the docs describing that SCIM-sourced deprovisioning is eventual (OIDC-login-gated), not immediate. Operators need to know to communicate this to their security teams when evaluating the integration.
How can I resolve this? If you propose a fix, please make it concise.
Summary
ScimGroupmodel — lightweight record for SCIM protocol compliance (stable IDs), decoupled from TeamScimGrouprecords insteadoidcTeamMappings) to create/removeTeamMemberrecords in the correct teams with the correct rolesgroup-mappings.ts:loadGroupMappings,getMappingsForGroup,applyMappedMemberships,removeMappedMembershipsresolveScimRole(superseded by mapping helpers)How it works
Unmapped SCIM groups are acknowledged (SCIM protocol satisfied) but create no team memberships.
Test plan
ScimGrouprecords created (not teams)